19 — 基于滚动的动画

Three.js Journey · Bruno Simon · 难度:简单

19 — 基于滚动的动画 课程海报

介绍

只由 WebGL 构成的体验很棒,但有时候,你会希望这个体验是经典网站的一部分。

这个体验可以放在背景里,给页面增添一些美感,但那样的话,你会希望这个体验能和 HTML 内容正确地融为一体。

这节课,我们将学习如何把 Three.js 用作经典 HTML 页面的背景。我们会让相机跟着滚动平移。我们会发现一些让这个滚动更身临其境的技巧。我们会根据光标位置添加一个炫酷的视差动画。最后,当我们到达相应区块时,会触发一些动画。

设置

这节课是练习我们已经学过的许多技巧的好机会。环境没有预先搭好,大部分工作都要我们自己完成。

OrbitControls 已经被移除,因为我们希望相机跟着滚动移动,并且不让用户像之前的课程那样随意旋转。

lil-gui 已经可用,而且已经创建了一个颜色,供以后使用:

/**
 * Debug
 */
const gui = new GUI()

const parameters = {
    materialColor: '#ffeded'
}

gui.addColor(parameters, 'materialColor')

一些非常简单的 HTML 内容已经设置了。目前你只能看到一个标题,但正下方有两个区块。我们看不到它们,因为滚动被锁定了。

HTML 滚动

激活滚动

在课程更早的时候,我们用这段 CSS 停用了滚动:

html,
body
{
    overflow: hidden;
}

要重新激活它,在 /src/style.css 里移除 overflow 那一行。

lesson demo video preview

你应该能滚动并看到下面另外两个区块了。

修复弹性滚动

在某些环境下,你可能会注意到,如果滚动过头,页面超出边界时会出现一种弹性动画效果。

lesson demo video preview

虽然这是个很酷的特性,但默认情况下,页面背面是白色的,和我们的体验不搭。

要修复这个,我们本可以把页面的 background-color 设为和 renderer 的 clearColor 一样的颜色。但我们改用另一种方式:把 clearColor 设为透明,只给页面设置 background-color。

要做到这一点,在 /src/script.js 里,你需要把 WebGLRenderer 上的 alpha 属性设为 true:

const renderer = new THREE.WebGLRenderer({
    canvas: canvas,
    alpha: true
})

默认情况下,clear 的 alpha 值是 0,这就是为什么我们不需要自己设置它。告诉 renderer 处理 alpha 就够了。但如果你想改变那个值,可以用 setClearAlpha 来做:

renderer.setClearAlpha(0)

我们现在能看到白色(本页背面)了。

在 /src/style.css 里,给 CSS 中的 html 添加一个 background-color:

html
{
    background: #1e1a20;
}
lesson demo video preview

我们得到了一种漂亮的统一背景色,弹性滚动也不再是问题了。

物体

我们要为每个区块创建一个物体来代表它们。

为了保持简单,我们会使用 Three.js 基础几何体,但你可以创建任何你想要的东西。而且在这个课程后面,你会学到如何把自定义模型导入场景。

在 /src/script.js 里,移除立方体的代码。在它的位置,用 TorusGeometry、ConeGeometry 和 TorusKnotGeometry 创建三个 Mesh:

/**
 * Objects
 */
// Meshes
const mesh1 = new THREE.Mesh(
    new THREE.TorusGeometry(1, 0.4, 16, 60),
    new THREE.MeshBasicMaterial({ color: '#ff0000' })
)
const mesh2 = new THREE.Mesh(
    new THREE.ConeGeometry(1, 2, 32),
    new THREE.MeshBasicMaterial({ color: '#ff0000' })
)
const mesh3 = new THREE.Mesh(
    new THREE.TorusKnotGeometry(0.8, 0.35, 100, 16),
    new THREE.MeshBasicMaterial({ color: '#ff0000' })
)

scene.add(mesh1, mesh2, mesh3)

所有物体当然都叠在一起。我们稍后会修复这个。

同样,为了保持简单,我们的代码会有点冗余。但如果你有更多区块,不要犹豫,可以用数组或其他代码组织方案。

材质

基础材质

我们要在全部三个 Mesh 上使用 MeshToonMaterial。

和材质课程一样,我们只创建一个材质实例,并把它用在全部三个 Mesh 上。

创建 MeshToonMaterial 时,用 parameters.materialColor 作为 color 属性,并把它应用到全部 3 个 Mesh 上:

// Material
const material = new THREE.MeshToonMaterial({ color: parameters.materialColor })

// Meshes
const mesh1 = new THREE.Mesh(
    new THREE.TorusGeometry(1, 0.4, 16, 60),
    material
)
const mesh2 = new THREE.Mesh(
    new THREE.ConeGeometry(1, 2, 32),
    material
)
const mesh3 = new THREE.Mesh(
    new THREE.TorusKnotGeometry(0.8, 0.35, 100, 16),
    material
)

scene.add(mesh1, mesh2, mesh3)

不幸的是,物体现在看起来是黑的。

原因是 MeshToonMaterial 是那种只有灯光存在时才显现的 Three.js 材质之一。

灯光

给场景添加一盏 DirectionalLight:

/**
 * Lights
 */
const directionalLight = new THREE.DirectionalLight('#ffffff', 3)
directionalLight.position.set(1, 1, 0)
scene.add(directionalLight)

你现在应该能看到你的物体了。

我们用的是存储在 parameters 对象里的颜色,但用 Tweaker 修改这个值并不会改变材质本身。

要修复这个,我们可以监听已有调整项的 change 事件,并相应地更新材质:

gui
    .addColor(parameters, 'materialColor')
    .onChange(() =>
    {
        material.color.set(parameters.materialColor)
    })

渐变纹理

正如我们在材质课程看到的,默认情况下 MeshToonMaterial 在受光部分是一种颜色,在阴影部分是更暗的颜色。

我们可以通过提供渐变纹理来改进这一点。

/static/textures/gradients/ 文件夹里提供了两张渐变图片。

在实例化 material 之前实例化 TextureLoader。然后加载 textures/gradients/3.jpg 纹理:

// Texture
const textureLoader = new THREE.TextureLoader()
const gradientTexture = textureLoader.load('textures/gradients/3.jpg')

把它用在材质的 gradientMap 属性上:

// Material
const material = new THREE.MeshToonMaterial({
    color: parameters.materialColor,
    gradientMap: gradientTexture
})

不是我们期待的卡通效果。

原因是这个纹理是一张非常小的图片,由 3 个从暗到亮的像素组成。默认情况下,WebGL 不是选取纹理上最近的像素,而是尝试插值像素。这通常对我们的体验观感有好处,但在这里,它制造出了渐变而不是卡通效果。

要修复这个,我们需要把纹理的 magFilter 设为 THREE.NearestFilter,这样就会使用最接近的像素,而不会和相邻像素插值:

const gradientTexture = textureLoader.load('textures/gradients/3.jpg')
gradientTexture.magFilter = THREE.NearestFilter

好多了,但我们仍然需要正确摆放这些 mesh。

位置

默认情况下,在 Three.js 里,视场角(field of view)是垂直的。这意味着如果你把一个物体放在渲染的上部、一个物体放在渲染的下部,然后调整窗口大小,你会注意到物体保持在顶部和底部。

为了演示这一点,临时添加这段代码:

mesh1.position.y = 2
mesh1.scale.set(0.5, 0.5, 0.5)

mesh2.visible = false

mesh3.position.y = - 2
mesh3.scale.set(0.5, 0.5, 0.5)
lesson demo video preview

圆环保持在顶部,圆环结保持在底部。完成后移除上面的代码。

这很好,因为这意味着我们只需要确保每个物体在 y 轴上离其他物体足够远,这样我们就不会同时看到它们。

创建一个 objectsDistance 变量,选一个随意的值,比如 2:

const objectsDistance = 2

用这个变量在 y 轴上摆放 mesh。这些值必须是负数,物体才会向下移动:

mesh1.position.y = - objectsDistance * 0
mesh2.position.y = - objectsDistance * 1
mesh3.position.y = - objectsDistance * 2

增大 objectsDistance,直到物体间距离足够远。一个好的数值应该是 4,但你也可以之后再改回来。

const objectsDistance = 4

现在,我们只能看到第一个物体。另外两个在下面。等我们用滚动移动相机、它们再次出现时,再水平摆放它们。

objectsDistance 稍后会派上大用场,这就是为什么我们把这个值保存在变量里。

持续旋转

为了让体验更有生命力,我们要给物体加一个持续旋转。

首先,把物体添加到一个 sectionMeshes 数组:

const sectionMeshes = [ mesh1, mesh2, mesh3 ]

然后,在 tick 函数里,遍历 sectionMeshes 数组,用已有的 elapsedTime 应用一个缓慢的旋转:

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Animate meshes
    for(const mesh of sectionMeshes)
    {
        mesh.rotation.x = elapsedTime * 0.1
        mesh.rotation.y = elapsedTime * 0.12
    }

    // ...
}
lesson demo video preview

所有 mesh(虽然这里只能看到一个)都应该缓慢旋转。

相机

滚动

是时候让相机跟着滚动移动了。

首先,我们需要获取滚动值。这可以用 window.scrollY 属性实现。

创建一个 scrollY 变量,把 window.scrollY 赋给它:

/**
 * Scroll
 */
let scrollY = window.scrollY

但之后,我们需要在用户滚动时更新这个值。为此,监听 window 上的 'scroll' 事件:

window.addEventListener('scroll', () =>
{
    scrollY = window.scrollY

    console.log(scrollY)
})

你应该会在日志里看到滚动值。移除 console.log。

在 tick 函数里,用 scrollY 移动相机(在渲染之前做):

const tick = () =>
{
    // ...

    // Animate camera
    camera.position.y = scrollY

    // ...
}
lesson demo video preview

还不太对。相机太灵敏了,而且方向也反了。我们需要对这个值稍微处理一下。

scrollY 在向下滚动时是正数,但相机在 y 轴上应该向下移动。让我们反转这个值:

camera.position.y = - scrollY
lesson demo video preview

好一些,但还是太灵敏了。原因如下。

scrollY 包含已经滚动的像素数量。如果我们滚动 1000 像素(这并不多),相机就会在场景里向下移动 1000 个单位(这很多)。

每个区块的大小和视口完全相同。这意味着当我们滚动一个视口高度的距离时,相机应该到达下一个物体。

要做到这一点,我们需要把 scrollY 除以视口的高度,也就是 sizes.height:

camera.position.y = - scrollY / sizes.height

相机现在每滚动一个区块就向下移动 1 个单位。但目前物体之间的间隔是 4 个单位,也就是 objectsDistance 变量:

我们需要把这个值乘以 objectsDistance:

camera.position.y = - scrollY / sizes.height * objectsDistance
lesson demo video preview

简而言之,如果用户向下滚动一个区块,相机就会移动到下一个物体。

水平摆放物体

现在是把物体左右摆放、以对齐标题的好时机:

mesh1.position.x = 2
mesh2.position.x = - 2
mesh3.position.x = 2
lesson demo video preview

视差

我们把通过不同观察点看同一个物体称为视差(parallax)。我们的眼睛天然做到这一点,也正是这样我们感受到事物的深度。

为了让我们的体验更身临其境,我们要通过让相机根据鼠标移动水平、垂直移动来应用这个视差效果。它会创造出自然的交互,并帮助用户感受深度。

光标

首先,我们需要获取光标位置。

为此,创建一个带 x 和 y 属性的 cursor 对象:

/**
 * Cursor
 */
const cursor = {}
cursor.x = 0
cursor.y = 0

然后,监听 window 上的 mousemove 事件并更新这些值:

window.addEventListener('mousemove', (event) =>
{
    cursor.x = event.clientX
    cursor.y = event.clientY

    console.log(cursor)
})
lesson demo video preview

你应该会在控制台里得到光标的像素位置。

虽然我们可以直接用这些值,但把它们适配到上下文里总是更好。

首先,振幅取决于视口的大小,屏幕分辨率不同的用户会有不同的结果。我们可以把值归一化(从 0 到 1),方法是把它除以视口的大小:

window.addEventListener('mousemove', (event) =>
{
    cursor.x = event.clientX / sizes.width
    cursor.y = event.clientY / sizes.height

    console.log(cursor)
})

虽然这已经更好了,但我们还能做得更多。

我们知道相机左右能移动的幅度是一样的。这就是为什么,与其用一个从 0 到 1 的值,不如用一个从 -0.5 到 0.5 的值。

要做到这点,减去 0.5:

window.addEventListener('mousemove', (event) =>
{
    cursor.x = event.clientX / sizes.width - 0.5
    cursor.y = event.clientY / sizes.height - 0.5

    console.log(cursor)
})
lesson demo video preview

这是一个适配到上下文里的干净值。

移除 console.log。

我们现在可以在 tick 函数里使用光标值了。创建一个 parallaxX 和一个 parallaxY 变量,把 cursor.x 和 cursor.y 放进去:

const tick = () =>
{
    // ...

    // Animate camera
    camera.position.y = - scrollY / sizes.height * objectsDistance

    const parallaxX = cursor.x
    const parallaxY = cursor.y
    camera.position.x = parallaxX
    camera.position.y = parallaxY

    // ...
}

不幸的是,我们有两个问题。x 和 y 轴在方向上似乎不同步。而且,相机滚动不再生效了。

让我们修复第一个问题。当我们把光标向左移动,相机似乎也向左移动。右边也一样。但当我们把光标向上移动,相机似乎向下移动,光标向下移动则相反。

要修复这种奇怪的感觉,反转 cursor.y:

const parallaxX = cursor.x
const parallaxY = - cursor.y
camera.position.x = parallaxX
camera.position.y = parallaxY
lesson demo video preview

对于第二个问题,问题在于我们更新了两次 camera.position.y,第二次会覆盖第一次。

要修复这个,我们打算把相机放进一个 Group,把视差应用到 group 上,而不是相机本身。

在实例化 camera 之前,创建 Group,把它加到场景,并把 camera 加进 Group:

/**
 * Camera
 */
// Group
const cameraGroup = new THREE.Group()
scene.add(cameraGroup)

// Base camera
const camera = new THREE.PerspectiveCamera(35, sizes.width / sizes.height, 0.1, 100)
camera.position.z = 6
cameraGroup.add(camera)

这不应该改变结果,但现在,相机在 group 里了。

在 tick 函数里,与其把视差应用到相机上,不如应用到 cameraGroup 上:

const tick = () =>
{
    // ...

    // Animate camera
    camera.position.y = - scrollY / sizes.height * objectsDistance

    const parallaxX = cursor.x
    const parallaxY = - cursor.y
    
    cameraGroup.position.x = parallaxX
    cameraGroup.position.y = parallaxY

    // ...
}
lesson demo video preview

滚动动画和视差动画现在很好地混合在一起了。但我们还能做得更好。

缓动

视差动画是个好的开始,但感觉有点太机械了。在现实生活中有很多原因让这种线性动画不可能实现:相机有重量、空气和表面有摩擦力、肌肉不可能做出这样线性的移动等等。这就是为什么现在这个移动感觉有点不对劲。我们要添加一些"easing"(也叫"smoothing"或"lerping"),并使用一个著名的公式。

这个公式背后的思路是,在每一帧上,我们不把相机直接移到目标,而是移到(比如说)离目的地近十分之一的地方。然后在下一帧,再近十分之一。然后在下一帧,再近十分之一。

在每一帧上,相机都会离目的地近一点。但越接近,移动得就越慢,因为它总是移动当前位置到目标位置之间的十分之一。

首先,我们需要把 = 改成 +=,因为我们要加到当前位置上:

cameraGroup.position.x += parallaxX
    cameraGroup.position.y += parallaxY

然后,我们需要计算从当前位置到目的地的距离:

cameraGroup.position.x += (parallaxX - cameraGroup.position.x)
    cameraGroup.position.y += (parallaxY - cameraGroup.position.y)

最后,我们只要那段距离的十分之一:

cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * 0.1
    cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * 0.1
lesson demo video preview

动画感觉平滑多了,但还有一个你们中一些人可能已经注意到的问题。

如果你在高刷新率屏幕上测试,tick 函数会被更频繁地调用,相机就会更快地移向目标。虽然这不是大问题,但不精确,而且最好尽量让不同设备的体验一致。

要修复这个,我们需要使用每帧之间经过的时间。

在实例化 Clock 之后,立即创建一个 previousTime 变量:

const clock = new THREE.Clock()
let previousTime = 0

在 tick 函数开头、设置 elapsedTime 之后,用 elapsedTime 减去 previousTime 计算 deltaTime:

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()
    const deltaTime = elapsedTime - previousTime

    // ...
}

然后,更新 previousTime,供下一帧使用:

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()
    const deltaTime = elapsedTime - previousTime
    previousTime = elapsedTime

    console.log(deltaTime)

    // ...
}

你现在有了当前帧和上一帧之间经过的时间,单位是秒。对于高刷新率屏幕,这个值会更小,因为需要的时间更少。

我们现在可以在视差上使用 deltaTime 了,但因为 deltaTime 的单位是秒,这个值会非常小(大多数常见的 60fps 屏幕大约 0.016)。因此,效果会非常慢。

要修复这个,我们可以把 0.1 改成像 5 这样的值:

cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * 5 * deltaTime
    cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * 5 * deltaTime
lesson demo video preview

我们现在有了一个不错的缓动,在不同屏幕刷新率下感觉都会一样。

最后,既然动画已经设置好了,我们可以降低效果的振幅:

const parallaxX = cursor.x * 0.5
    const parallaxY = - cursor.y * 0.5

粒子

让体验更身临其境、帮助用户感受深度的一个好方法,是添加粒子。

我们要创建非常简单的方形粒子,并把它们散布在场景周围。

因为我们需要自己摆放粒子,我们要像粒子和星系生成器课程那样创建自定义 BufferGeometry。

创建一个 particlesCount 变量,并用 Float32Array 创建一个 positions 变量:

/**
 * Particles
 */
// Geometry
const particlesCount = 200
const positions = new Float32Array(particlesCount * 3)

创建一个循环,往 positions 数组里添加随机坐标:

for(let i = 0; i < particlesCount; i++)
{
    positions[i * 3 + 0] = Math.random()
    positions[i * 3 + 1] = Math.random()
    positions[i * 3 + 2] = Math.random()
}

我们稍后会改这些位置,但先保持简单,确保我们的几何体能工作。

实例化 BufferGeometry 并设置 position attribute:

const particlesGeometry = new THREE.BufferGeometry()
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))

用 PointsMaterial 创建材质:

// Material
const particlesMaterial = new THREE.PointsMaterial({
    color: parameters.materialColor,
    sizeAttenuation: true,
    size: 0.03
})

用 Points 创建粒子:

// Points
const particles = new THREE.Points(particlesGeometry, particlesMaterial)
scene.add(particles)

你应该会得到一片散布在立方体里的粒子。

这部分我们过得很快,因为我们在更早的课程已经学过怎么做粒子了。如果你遇到困难,这是正常的。这仍然有点难,而且容易出错。

我们现在可以把粒子摆到三个轴上了。

对于 x(水平)和 z(深度),我们可以使用正负各半的随机值:

for(let i = 0; i < particlesCount; i++)
{
    positions[i * 3 + 0] = (Math.random() - 0.5) * 10
    positions[i * 3 + 1] = Math.random()
    positions[i * 3 + 2] = (Math.random() - 0.5) * 10
}
lesson demo video preview

对于 y(垂直)就稍微棘手一些。我们需要让粒子从足够高的地方开始,然后向下铺开足够远,这样我们就能滚动到底。

要做到这一点,我们可以用 objectsDistance 变量,乘以物体数量,也就是 sectionMeshes 数组的 length:

for(let i = 0; i < particlesCount; i++)
{
    positions[i * 3 + 0] = (Math.random() - 0.5) * 10
    positions[i * 3 + 1] = objectsDistance * 0.5 - Math.random() * objectsDistance * sectionMeshes.length
    positions[i * 3 + 2] = (Math.random() - 0.5) * 10
}
lesson demo video preview

和物体的材质一样,我们可以在调整项的 change 事件里更新粒子的颜色:

gui
    .addColor(parameters, 'materialColor')
    .onChange(() =>
    {
        material.color.set(parameters.materialColor)
        particlesMaterial.color.set(parameters.materialColor)
    })

粒子就这些了,但显然你可以像我们在之前的课程里那样改进它们:随机大小、随机 alpha。而且,我们甚至可以给它们做动画。

触发的旋转

作为最后一个功能,也让这个练习稍微难一点,我们要让物体在到达对应区块时,在持续旋转之外再做一个小小的转身。

知道何时触发动画

首先,我们需要一种方法知道我们什么时候到达某个区块。做到这一点有很多方式,我们甚至可以用一个库,但我们的情况可以用 scrollY 值,做一些数学运算找出当前区块。

创建 scrollY 变量之后,创建一个 currentSection 变量并设为 0:

let scrollY = window.scrollY
let currentSection = 0

在 'scroll' 事件回调函数里,把 scrollY 除以 sizes.height 来计算当前区块:

window.addEventListener('scroll', () =>
{
    scrollY = window.scrollY

    const newSection = scrollY / sizes.height
    
    console.log(newSection)
})
lesson demo video preview

这之所以有效,是因为每个区块正好是一个视口的高度。

要得到确切的区块而不是这个浮点值,我们可以用 Math.round():

window.addEventListener('scroll', () =>
{
    scrollY = window.scrollY

    const newSection = Math.round(scrollY / sizes.height)
    
    console.log(newSection)
})
lesson demo video preview

我们现在可以测试 newSection 是否和 currentSection 不同。如果不同,说明我们换了区块,就可以更新 currentSection 来做我们的动画:

window.addEventListener('scroll', () =>
{
    scrollY = window.scrollY
    const newSection = Math.round(scrollY / sizes.height)

    if(newSection != currentSection)
    {
        currentSection = newSection

        console.log('changed', currentSection)
    }
})
lesson demo video preview

让 mesh 动起来

我们现在可以让 mesh 动起来了,为此我们要使用 GSAP。

在终端运行 npm install gsap@3.12 把 GSAP 添加到依赖里。我们用 GSAP 一个非常具体的版本,确保代码一致。如果你想用最新版本,运行 npm install gsap,但你可能需要用不同的语法。

如果你收到漏洞警告,别在意。它不危险。

依赖添加好之后,在代码开头导入 GSAP:

import gsap from 'gsap'

然后,在我们之前做的 if 语句里,我们可以用 gsap.to() 做动画:

window.addEventListener('scroll', () =>
{
    // ...
    
    if(newSection != currentSection)
    {
        // ...

        gsap.to(
            sectionMeshes[currentSection].rotation,
            {
                duration: 1.5,
                ease: 'power2.inOut',
                x: '+=6',
                y: '+=3'
            }
        )
    }
})

虽然这段代码是有效的,但它不幸不会生效。原因是在每一帧,我们都已经用 elapsedTime 更新了每个 mesh 的 rotation.x 和 rotation.y。

要修复这个,在 tick 函数里,与其基于 elapsedTime 设置一个非常具体的旋转,我们不如把 deltaTime 加到当前旋转上:

const tick = () =>
{
    // ...

    for(const mesh of sectionMeshes)
    {
        mesh.rotation.x += deltaTime * 0.1
        mesh.rotation.y += deltaTime * 0.12
    }

    // ...
}
lesson demo video preview

要给动画加更多随机性,尤其是圆锥,我们也可以动画化 z 轴:

gsap.to(
            sectionMeshes[currentSection].rotation,
            {
                duration: 1.5,
                ease: 'power2.inOut',
                x: '+=6',
                y: '+=3',
                z: '+=1.5'
            }
        )
lesson demo video preview

继续深入

我们保持得非常简单,但你可以走得远得多。